﻿/*
 * Copyright (C) 2008 Adrian Macneil <adrian dot macneil at gmail>
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using Microsoft.Win32;

namespace Audioscrobbler.ClientInteraction
{
    /// <summary>
    /// This class is for Last.fm plugins to use to enable them to easily communicate
    /// with the Last.fm client, to enable scrobbling on supported platforms. Plugins
    /// must create an instance of Submitter. They may then call Start(), Stop(),
    /// Pause() and Resume() to update the Last.fm client software. These methods are
    /// processed asynchronously.
    /// </summary>
    public class Submitter
    {
        private string _pluginId;
        private DateTime _launchTime;
        private int _actualPort;
        private object _clientSocketLock = new object();

        /// <summary>
        /// Port to search for the Last.fm client on.
        /// </summary>
        public int DefaultPort { get; set; }

        /// <summary>
        /// Number of ports above the default port to search for the Last.fm client on.
        /// </summary>
        public int PortsToStep { get; set; }

        /// <summary>
        /// The identifier string for the plugin.
        /// </summary>
        public string PluginId { get { return _pluginId; } }

        /// <summary>
        /// Creates a new instance of ScrobSubmitter. Tries to launch the Last.fm client
        /// if it can be found, and auto-launch hasn't been disabled in the registry.
        /// </summary>
        /// <param name="pluginId">The identifier string for the plugin.</param>
        public Submitter(string pluginId)
        {
            DefaultPort = 33367;
            PortsToStep = 5;
            _pluginId = pluginId;

            // start the Last.fm client
            if (GetAutoLaunchClient())
            {
                LaunchClient();
            }

            // initialise the port connect on
            _actualPort = DefaultPort;

            // save the current time
            _launchTime = DateTime.Now;
        }

        /// <summary>
        /// Sends a START command to the client.
        /// </summary>
        /// <param name="artist">Artist name</param>
        /// <param name="track">Track name</param>
        /// <param name="album">Album name</param>
        /// <param name="mbId">Musicbrainz ID if present</param>
        /// <param name="length">Length of the track in seconds</param>
        /// <param name="filename">Full path to the file on disk</param>
        /// <returns>A unique state object relating to this request</returns>
        public object Start(string artist, string track, string album, string mbId, int length, string filename)
        {
            string cmd = "START " +
                "c=" + _pluginId +
                "&a=" + EscapeString(artist) +
                "&t=" + EscapeString(track) +
                "&b=" + EscapeString(album) +
                "&m=" + EscapeString(mbId) +
                "&l=" + length +
                "&p=" + EscapeString(filename);

            return SendCommandAsync(cmd);
        }

        /// <summary>
        /// Sends a STOP command to the client. Use this both for manual stops and when
        /// the song plays to the end.
        /// </summary>
        /// <returns>A unique state object relating to this request</returns>
        public object Stop()
        {
            return SendCommandAsync("STOP c=" + _pluginId);
        }

        /// <summary>
        /// Sends a PAUSE command to the client. Use this only for manual pauses.
        /// </summary>
        /// <returns>A unique state object relating to this request</returns>
        public object Pause()
        {
            return SendCommandAsync("PAUSE c=" + _pluginId);
        }

        /// <summary>
        /// Sends a RESUME command to the client. Use this only after a PAUSE command.
        /// </summary>
        /// <returns>A unique state object relating to this request</returns>
        public object Resume()
        {
            return SendCommandAsync("RESUME c=" + _pluginId);
        }

        /// <summary>
        /// This event is triggered after a command is completed. It can be handled
        /// to discover any errors in communicating with the Last.fm client.
        /// </summary>
        public event AsyncCompletedEventHandler CommandCompleted;

        /// <summary>
        /// Sends a command to the Last.fm asynchronously, using the ThreadPool to
        /// process the request, and an AsyncOperation to ensure the correct thread
        /// context is used for the CommandCompleted event.
        /// </summary>
        /// <param name="command">The command to send</param>
        /// <returns>A unique state object relating to this request</returns>
        private object SendCommandAsync(string command)
        {
            // create a new request
            Request r = new Request();
            r.Command = command;
            r.State = Guid.NewGuid();

            // process it asynchronously
            AsyncOperation ao = AsyncOperationManager.CreateOperation(r);
            ThreadPool.QueueUserWorkItem(ProcessRequestCallback, ao);

            // return the request state
            return r.State;
        }

        /// <summary>
        /// Communicates with the Last.fm client. Used by the background thread.
        /// </summary>
        private void ProcessRequestCallback(object state)
        {
            AsyncOperation ao = (AsyncOperation)state;
            Request r = (Request)ao.UserSuppliedState;

            // only process one command at a time;
            Monitor.Enter(_clientSocketLock);
            
            try
            {
                // Last.fm client needs a different connection for each command
                using (Socket s = ConnectToClient())
                {
                    // check that the connection was successful
                    if (s == null)
                    {
                        throw new IOException("The client could not be reached.");
                    }

                    // send command to client
                    s.Send(Encoding.UTF8.GetBytes(r.Command + "\n"));
                    
                    // receive response from client
                    using (StreamReader sr = new StreamReader(new NetworkStream(s), Encoding.UTF8))
                    {
                        string response = sr.ReadLine();

                        // check for error message
                        if (!response.StartsWith("OK"))
                        {
                            throw new InvalidOperationException(response);
                        }
                    }

                    // report success (the AsyncOperation ensures that the callback
                    // is completed under the correct context)
                    ao.PostOperationCompleted(ProcessRequestCompleted,
                        new AsyncCompletedEventArgs(null, false, r.State));
                }
            }
            catch (Exception e)
            {
                ao.PostOperationCompleted(ProcessRequestCompleted,
                    new AsyncCompletedEventArgs(e, false, r.State));
            }
            finally
            {
                Monitor.Exit(_clientSocketLock);
            }
        }

        /// <summary>
        /// ProcessRequestCompleted is called once ProcessRequestCallback completes,
        /// and triggers the CommandCompleted event.
        /// </summary>
        /// <param name="state">The AsyncCompletedEventArgs for this request</param>
        private void ProcessRequestCompleted(object state)
        {
            if (CommandCompleted != null)
            {
                CommandCompleted(this, (AsyncCompletedEventArgs)state);
            }
        }

        /// <summary>
        /// Attempts to get a value from the registry which specifies whether to
        /// automatically launch the Last.fm client along with the media player (plugin).
        /// </summary>
        /// <returns>Whether to launch the client. Defaults to true.</returns>
        private static bool GetAutoLaunchClient()
        {
            string strEnableAutoLaunch =
            (string)Registry.GetValue("HKEY_CURRENT_USER\\Software\\Last.fm\\Client", "LaunchWithMediaPlayer", "");

            // if bool.TryParse fails, then it returns false, so we want to set a default
            bool bEnableAutoLaunch;
            if (!bool.TryParse(strEnableAutoLaunch, out bEnableAutoLaunch))
            {
                bEnableAutoLaunch = true;
            }

            return bEnableAutoLaunch;
        }

        /// <summary>
        /// Attempts to launch the Last.fm client.
        /// </summary>
        /// <returns>Whether the execution was successful</returns>
        private static bool LaunchClient()
        {
            try
            {
                // try to find LastFM.exe in HKCU first
                string exePath = (string)Registry.GetValue("HKEY_CURRENT_USER\\Software\\Last.fm\\Client", "Path", "");

                if (string.IsNullOrEmpty(exePath))
                {
                    // try HKLM next
                    exePath = (string)Registry.GetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\Last.fm\\Client", "Path", "");
                }

                if (string.IsNullOrEmpty(exePath))
                {
                    // try hard-coded default
                    exePath = "C:\\Program Files\\Last.fm\\LastFM.exe";
                }

                // launch last.fm client (it doesn't allow multiple instances of itself)
                Process p = Process.Start(exePath, "--tray");

                // if that didn't throw an exception, then it worked!
                return true;
            }
            catch (Exception)
            {
                return false; // if anything didn't work, then give up
            }
        }

        /// <summary>
        /// Creates and connects a socket to the Last.fm client, and tries
        /// to intelligently solve errors by giving the client a grace period
        /// after start-up, and port-stepping.
        /// </summary>
        /// <returns>The connected socket, or null if unsuccessful.</returns>
        private Socket ConnectToClient()
        {
            Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            s.Connect(IPAddress.Loopback, _actualPort);

            if (s.Connected)
            {
                return s;
            }

            else if (_launchTime.AddSeconds(30) > DateTime.Now)
            {
                // give the client app a grace period to start up
                Thread.Sleep(2000);
                return ConnectToClient();
            }

            else if (_actualPort <= DefaultPort + PortsToStep)
            {
                // try again on a higher port
                _actualPort++;
                return ConnectToClient();
            }

            else
            {
                // if port-stepping didn't work, reset the port and give up
                _actualPort = DefaultPort;
                return null;
            }
        }

        /// <summary>
        /// Escapses a string, ready to send to Last.fm client.
        /// This is a seperate function in case the encoding changes in future.
        /// </summary>
        /// <param name="stringToEscape">The string to escape</param>
        /// <returns>The escaped string</returns>
        private static string EscapeString(string stringToEscape)
        {
            if (stringToEscape == null) { return null; }
            else { return stringToEscape.Replace("&", "&&"); }
        }

        private struct Request
        {
            public object State;
            public string Command;
        }
    }
}
